In [1]:
import os
import pandas as pd
import ast


def join_csvs_in_folder(folder_path):
    # listamos ficheros en carpeta
    csv_files = [f for f in os.listdir(folder_path) if f.endswith('.csv')]

    data_frames = []
    
    # leo los csv y los pongo en una lista
    for file in csv_files:
        # low_memory=False elimina los warnings que avisan de no haber indicado el tipo de dato
        df = pd.read_csv(os.path.join(folder_path, file), low_memory=False)
        # nombre de fichero como columna extra
        df["file_name"] = file
        data_frames.append(df)

    # concatenamos los dataframes
    df = pd.concat(data_frames, ignore_index = True)

    return df

La lectura de ficheros tan grandes en los que no hemos indicado el tipo de dato para cada columna previamente hace que pandas procese la información de manera muy lenta. Para optimizarlo, y eliminar el banner, bastaría con indicar los tipos de cada columna.

In [2]:
selected_csvs = join_csvs_in_folder("selection/")
In [3]:
#observamos los primeros 5 tweets del conjunto de datos
selected_csvs.head(5)
Out[3]:
Unnamed: 0 userid username acctdesc location following followers totaltweets usercreatedts tweetid ... original_tweet_id original_tweet_userid original_tweet_username in_reply_to_status_id in_reply_to_user_id in_reply_to_screen_name is_quote_status quoted_status_id quoted_status_userid quoted_status_username
0 1141800 1466752038960656385 Curtin2Tiffany I am just like everyone else. The universe ex... Colorado, USA 253 40 94 2021-12-03 12:52:22.000000 1497724980259262467 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
1 1141801 1111276809302216710 5ela60 الأب ..الأخ ..الجار ..الحبيب .. السديك NaN 167 9 656 2019-03-28 14:40:12.000000 1497724980271984641 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
2 1141802 1364735420236505088 StatistWomen 🇹🇷 Cumhur İttifakı 1771 1766 37009 2021-02-25 00:35:28.000000 1497724980322349058 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
3 1141803 597779527 OurTurnToRescue Issues: Threats to Democracy, Racism, GOP corr... NaN 4847 4080 33666 2012-06-02 21:53:59.000000 1497724980573966346 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
4 1141804 1646145848 DrWAVeSportCd1 Addicted to News, Music, Cooking, Gardens, Out... USA 5002 3950 466629 2013-08-04 21:07:08.000000 1497724980653694976 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN

5 rows × 30 columns

In [4]:
#listamos los diferentes campos que existen en el conjunto de datos
list(selected_csvs.columns)
Out[4]:
['Unnamed: 0',
 'userid',
 'username',
 'acctdesc',
 'location',
 'following',
 'followers',
 'totaltweets',
 'usercreatedts',
 'tweetid',
 'tweetcreatedts',
 'retweetcount',
 'text',
 'hashtags',
 'language',
 'coordinates',
 'favorite_count',
 'extractedts',
 'file_name',
 'is_retweet',
 'original_tweet_id',
 'original_tweet_userid',
 'original_tweet_username',
 'in_reply_to_status_id',
 'in_reply_to_user_id',
 'in_reply_to_screen_name',
 'is_quote_status',
 'quoted_status_id',
 'quoted_status_userid',
 'quoted_status_username']
In [5]:
selected_csvs.head() 
Out[5]:
Unnamed: 0 userid username acctdesc location following followers totaltweets usercreatedts tweetid ... original_tweet_id original_tweet_userid original_tweet_username in_reply_to_status_id in_reply_to_user_id in_reply_to_screen_name is_quote_status quoted_status_id quoted_status_userid quoted_status_username
0 1141800 1466752038960656385 Curtin2Tiffany I am just like everyone else. The universe ex... Colorado, USA 253 40 94 2021-12-03 12:52:22.000000 1497724980259262467 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
1 1141801 1111276809302216710 5ela60 الأب ..الأخ ..الجار ..الحبيب .. السديك NaN 167 9 656 2019-03-28 14:40:12.000000 1497724980271984641 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
2 1141802 1364735420236505088 StatistWomen 🇹🇷 Cumhur İttifakı 1771 1766 37009 2021-02-25 00:35:28.000000 1497724980322349058 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
3 1141803 597779527 OurTurnToRescue Issues: Threats to Democracy, Racism, GOP corr... NaN 4847 4080 33666 2012-06-02 21:53:59.000000 1497724980573966346 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
4 1141804 1646145848 DrWAVeSportCd1 Addicted to News, Music, Cooking, Gardens, Out... USA 5002 3950 466629 2013-08-04 21:07:08.000000 1497724980653694976 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN

5 rows × 30 columns

In [6]:
print("filas:"+str(len(selected_csvs))+ "; columnas:"+str(len(list(selected_csvs.columns))))
filas:10570131; columnas:30

Tenemos 30 columnas diferentes, y millones de filas con datos por analizar, visualizar, explotar. Como esta cantidad de datos sobrepasa lo que se pedía en la práctica, vamos a intentar reducirla por algún tipo de valor. Analicemos el idioma de los tweets.

In [7]:
# vamos a observar la cantidad de lenguas diferentes que hay en el conjunto de datos
selected_csvs.language.unique()
Out[7]:
array(['en', 'tr', 'uk', 'und', 'de', 'fr', 'es', 'pl', 'nl', 'it', 'ro',
       'et', 'th', 'ar', 'ja', 'hi', 'ta', 'pt', 'cs', 'ko', 'ur', 'sv',
       'el', 'zh', 'te', 'fa', 'cy', 'ru', 'ht', 'in', 'no', 'da', 'fi',
       'ca', 'gu', 'tl', 'mr', 'ml', 'lt', 'iw', 'bg', 'pa', 'kn', 'sl',
       'ne', 'bn', 'vi', 'lv', 'sd', 'my', 'hu', 'ka', 'ps', 'or', 'eu',
       'ckb', 'am', 'hy', 'is', 'sr', 'si', 'km', 'ug', 'dv', 'bo', 'lo'],
      dtype=object)

A continuación, visualizamos el top 10 de idiomas en que están los tweets.

In [8]:
import plotly.graph_objects as go
# contamos el total de tweets por cada lengua, y mostramos el top 10
language_counts = selected_csvs["language"].value_counts().head(10)

#hacemos diagrama de barras
fig = go.Figure(data=[go.Bar(x=language_counts.index, y=language_counts.values, 
                             marker=dict(color=['lightsteelblue', 'lightskyblue','skyblue', 'aqua', 'aquamarine','powderblue', 'darkturquoise', 'slateblue', 'dodgerblue', 'navy']))])

# añadimos etiquetas
fig.update_layout(
    title={
        'text': 'Top 10 de cantidad de tweets por idioma',
        'y':0.9,
        'x':0.5,
        'xanchor': 'center',
        'yanchor': 'top'}, xaxis_title='Language', yaxis_title='Count')
fig.show()

Como puede observarse, el idioma más hablado en los tweets es el inglés, seguido de "und", (undetermined, idioma no determinado). Esto puede deberse a que haya, por ejemplo, tweets que solo contengan emoticonos o imágenes. También resulta llamativo ver cómo hay más tweets en español que en ucraniano o ruso hablando del conflicto.

In [9]:
# top 10 de usuarios que han twiteado
import plotly.graph_objects as go

# contamos el total de tweets por cada lengua, y mostramos el top 10
top_twitteros = selected_csvs["username"].value_counts().head(10)
top_twitteros_id = selected_csvs["userid"].value_counts().head(10)
#hacemos diagrama de barras
fig = go.Figure(data=[go.Bar(x=top_twitteros.index, y=top_twitteros.values, 
                             marker=dict(color=['purple', 'blue', 'green','red','yellow','orange','pink','gray','brown','black']))])

# añadimos etiquetas
fig.update_layout(title={
        'text': 'Top 10 de twiteros',
        'y':0.9,
        'x':0.5,
        'xanchor': 'center',
        'yanchor': 'top'}, xaxis_title='Language', yaxis_title='Count')
fig.show()

Traemos la descripción de cada uno de los perfiles utilizando la API de twitter

In [10]:
import tweepy
import pandas as pd
from wordcloud import WordCloud
import matplotlib.pyplot as plt
import json
with open('details.json') as data_file:
    data = json.load(data_file)["credentials"]

# Autenticación
auth = tweepy.OAuthHandler(data["api_key"], data["api_key_secret"])
auth.set_access_token(data["access_token"], data["access_token_secret"])
api = tweepy.API(auth)

# Create an empty list to hold the descriptions
descriptions = []

# Loop through the list of usernames and retrieve the description for each one
for username in list(top_twitteros.keys()):
    try:
        # Get the user's information
        user = api.get_user(screen_name=username)
        # Append the user's description to the list
        print(username+":"+user.description)
    except:
        print(f"Error while processing {username}")
FuckPutinBot:I'm a bot. Every minute of every day, I tell Putin to go fuck himself in various languages of the world. #IStandWithUkraine 🇺🇦
kanadianbest:https://t.co/ZJK4Q3dy6l – Your Prime Discount Store For Computers|Electronic|Games|Toys|Drones|Everything Tech & More.
Easy Returns | Money Back Guarantee
bmurphypointman:#advocate #missing #missingperson #missingchild | #twitter #marketing #organization #nonprofit #fundraising #business #news #writerslift | #BMPRT For #Retweet
ArvadaRadio:Playing 80's - 90's Themed Rock. 
Featuring Local Bands. 
All Rock / All Local / All The Time 
Commercial Free! Tell All Your Friends!
rogue_corq:@corq's snark account. 

#cyber #linux #agitprop #cats #Україна 🤜 Bulture Brobby 🤛

🇺🇦 Currently in full #Ukraine News mode 🇺🇦

All cats retweeted.
poandpo:News and other interesting stories. Since 2007 https://t.co/655UNA7k59
MadrasTribune:Official Twitter of The Madras Tribune. For press releases/any queries mail us: madrastribune@gmail.com or info@madrastribune.com
TheAnswerYes:A fun Yes bot. A parody bot.
Don't hurt yourself or others.
If you are suicidal, please seek help to get better. 
Obey laws. Be kind. #TasteTheRainbow
BerkleyBearNews:Bringing the news that is important, that as quick as a dog can bring it.
IdeallyaNews:#WorldNews, compiled from public broadcasters across the world, and anonymised to reduce bias.

In combination, it presents a fairly balanced #NewsFeed

Parece ser que las cuentas más activas, o son bots o pertenecen a noticiarios reconocidos.

Tal y como habíamos adelantado, al existir una cantidad enorme de tweets, vamos a intentar reducir el tamaño para poder cubrir el tope de cantidad de filas que podía tener nuestro dataset. Tomamos los que estén en español solamente

In [11]:
spanish_tweets = selected_csvs[(selected_csvs['language']=='es')]
In [12]:
len(spanish_tweets)
Out[12]:
384543

Otro procesado importante que debemos hacer en este punto, es convertir el campo nombre de fichero a fecha. El dataset de Kaggle presenta el día en que se recopilaron los tweets en el título: 20230116_UkraineCombinedTweetsDeduped corresponde al 16 de enero de 2023.

In [13]:
spanish_tweets['date_in_file'] = spanish_tweets['file_name'].str.split('_').str[0]
spanish_tweets['date'] = pd.to_datetime(spanish_tweets['date_in_file'], format='%Y%m%d')
C:\Users\Javier\AppData\Local\Temp\ipykernel_15864\1405153101.py:1: SettingWithCopyWarning:


A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy

C:\Users\Javier\AppData\Local\Temp\ipykernel_15864\1405153101.py:2: SettingWithCopyWarning:


A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy

In [14]:
spanish_tweets['date'].head()
Out[14]:
32    2022-02-27
52    2022-02-27
308   2022-02-27
347   2022-02-27
350   2022-02-27
Name: date, dtype: datetime64[ns]

Ahora que tenemos un campo con la fecha del tweet, puede resultar de interés ver la cantidad de tweets que se han ido escribiendo con el paso de los días en el conflicto. La selección de datos que se ha hecho, es del conjunto de tweets con un intervalo de entre 5 y 7 días entre dumps.

In [15]:
spanish_sorted = spanish_tweets.sort_values(by='date')
date_count = spanish_sorted.groupby('date').count()


fig = go.Figure(data=go.Scatter(x=date_count.index, y=date_count['text'], mode='lines+markers'))
fig.update_layout(title='Cantidad de tuits por fecha', xaxis_title='Date', yaxis_title='Count')

# Display the graph
fig.show()

Este resultado no nos sorprende. Con el paso de los meses, el conflicto se va normalizando en las redes, y pierde el interés de los usuarios de habla hispana.

Como puntos a destacar:

  • El 5 de mayo de 2022, se produjeron las primeras evacuaciones masivas de ucranianos huyendo del conflicto en Mariupol, el primer ministro japonés anunció sanciones a Rusia, y la inteligencia estadounidense ayudaba al gobierno ucraniano a geolocalizar e interceptar un barco ruso en aguas ucranianas para cambiar el rumbo de la guerra naval.
  • El 3 de agosto de 2022 Alemania hace oficial que el acuerdo para traer el gas de Rusia con la turbina Nordstream 1 ya resulta inviable, y asume que queda anulado debido al desinterés del Kremlin por desescalar el conflicto.
  • El 23 de noviembre de 2022 el presidente Zelensky se une a la conferencia del Consejo de Seguridad de las Naciones Unidas para apelar por una "fórmula de la paz" ante el terror que está sembrando Rusia. También se empiezan a dar los primeros cortes de luz debido a que los rusos están bombardeando las infraestructuras eléctricas.

De todos los tweets en español, que siguen siendo una cantidad mayor a 10k, vamos a ver cuántos son retweets y cuántos no

In [16]:
counts = spanish_tweets['is_quote_status'].isna().value_counts()
fig = go.Figure(data=[go.Bar(x=counts.index, y=counts.values, marker=dict(color=['lightsteelblue', 'lightskyblue']))])
fig.update_layout(
title={
        'text': 'Cantidad de Tweets Originales vs Retweets',
        'y':0.9,
        'x':0.5,
        'xanchor': 'center',
        'yanchor': 'top'}, xaxis_title='es_retweet', yaxis_title='total')
fig.update_layout(xaxis = dict(tickvals=[True,False], ticktext=['tweets', 'retweets']))
fig.show()

Haciendo 'head' de tweets en español (contenido original, sin retweets), se ha observado que hay tweets sin hashtags

In [17]:
spanish_tweets_original = spanish_tweets[(spanish_tweets['is_quote_status'].isna())]
In [18]:
spanish_tweets_original['hashtags'].head(30)
Out[18]:
32      [{'text': 'Sukhoi', 'indices': [86, 93]}, {'te...
52                [{'text': 'Kyiv', 'indices': [47, 52]}]
308     [{'text': 'Ukraine', 'indices': [17, 25]}, {'t...
347                                                    []
350                                                    []
379              [{'text': 'war', 'indices': [135, 139]}]
478                                                    []
502     [{'text': 'Ucrania', 'indices': [159, 167]}, {...
514     [{'text': 'UCRANIA', 'indices': [2, 10]}, {'te...
577                                                    []
580     [{'text': 'NoALaGuerra', 'indices': [103, 115]...
583                                                    []
592                                                    []
641     [{'text': 'UcraniaBajoAtaque', 'indices': [80,...
660                                                    []
671     [{'text': 'Anonymous', 'indices': [16, 26]}, {...
696            [{'text': 'Ukraine', 'indices': [34, 42]}]
711                                                    []
743     [{'text': 'Rusia', 'indices': [116, 122]}, {'t...
757          [{'text': 'Ucrania', 'indices': [127, 135]}]
767     [{'text': 'Urgente', 'indices': [15, 23]}, {'t...
808     [{'text': 'Ukraine', 'indices': [230, 238]}, {...
826     [{'text': 'UcraniaRussia', 'indices': [19, 33]...
872     [{'text': 'Ukrania', 'indices': [42, 50]}, {'t...
874     [{'text': 'Anonymous', 'indices': [16, 26]}, {...
892     [{'text': 'Ukrania', 'indices': [93, 101]}, {'...
911     [{'text': 'UcraniaRussia', 'indices': [94, 108...
995          [{'text': 'Ukraine', 'indices': [124, 132]}]
1074         [{'text': 'Ukraine', 'indices': [124, 132]}]
1088           [{'text': 'Rusia', 'indices': [119, 125]}]
Name: hashtags, dtype: object

Observemos los hashtags que más se repiten

In [19]:
#convertimos a lista de diccionarios
spanish_tweets_original['hashtags'] = spanish_tweets_original['hashtags'].apply(lambda x: ast.literal_eval(x))
C:\Users\Javier\AppData\Local\Temp\ipykernel_15864\139796560.py:2: SettingWithCopyWarning:


A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy

In [20]:
from collections import Counter
# Define a function to count the occurrences of each hashtag
def count_hashtags(hashtags):
    hashtags_count = Counter()
    for ht in hashtags:
        hashtags_count[ht['text'].lower()] += 1
    return hashtags_count

# Count the occurrences of each hashtag
hashtags_count = spanish_tweets_original['hashtags'].apply(count_hashtags)
hashtags_count = sum(hashtags_count, Counter())
In [21]:
# Top 5 de hashtags
top_hashtags = hashtags_count.most_common(5)

# Creamos un horizontal bar chart
fig = go.Figure(data=[go.Bar(marker=dict(color=['lightsteelblue', 'lightskyblue','skyblue', 'aqua', 'aquamarine']),x=[ht[1] for ht in top_hashtags], y=[ht[0] for ht in top_hashtags], orientation='h')])
fig.update_layout(title='Top 5 Hashtags (sin retweets)', xaxis_title='Count', yaxis_title='Hashtag')
fig.show()

El hashtag más repetido, dentro de los tweets de contenido original, es 'ucrania'.

Veamos cuáles son las palabras más comunes a través de una nube de palabras, esta vez para todos los tweets en español, sean originales o retweets.

In [22]:
from wordcloud import WordCloud

hashtags=spanish_tweets['hashtags'].apply(lambda x: ast.literal_eval(x))
hashtags = [ht['text'] for ht_list in hashtags for ht in ht_list]
hashtags_print = list(set(hashtags))
wordcloud = WordCloud().generate(' '.join(hashtags_print))
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis("off")
plt.show()

Estudiar la interrelación entre usuarios o contenido, puede ser otra de las áreas más interesantes a explorar en un conjunto de datos de tweets. Comprendiendo cada usuario como un nodo que se interrelaciona con otros nodos, podríamos construir una red de grafos en que viésemos el peso de un usuario, por ejemplo, a partir de la cantidad de followers que tiene. Para representar la interconexión de los usuarios entre sí, podemos crear un chord diagram que muestre los IDs de usuario interconectados.

En un primer intento, se creó con todo el conjunto de datos, pero al aparecer demasiado denso y poco legible, se ha reducido esta visualización al top 100 de usuarios con mayor número de followers. También se eliminan aquellos casos en que los usuarios no son seguidos por otros del conjunto de datos, a fin de simplificar la gráfica aún más.

In [23]:
import pandas as pd
import holoviews as hv
from holoviews import opts, dim
from bokeh.sampledata.les_mis import data

hv.extension('bokeh')
hv.output(size=200)
In [24]:
# conjunto de datos completo
# cooccurrences = spanish_tweets.groupby(['userid','original_tweet_userid']).size().reset_index(name='value')
# cooccurrences['original_tweet_userid'].apply(int)
# cooccurrences3 = cooccurrences[cooccurrences.original_tweet_userid != 0]
# chord = hv.Chord(cooccurrences3)
# chord.opts(
#     opts.Chord(edge_color='value', labels='index', node_color='index', cmap='Category20', edge_cmap='Category20', fontsize={'labels': '12pt'}),
#     title='Usernames quoting other usernames')
In [25]:
# ordenamos por cantidad de followers y username, y después tomamos el top 500
top_followers = spanish_tweets.groupby('username')['followers'].mean()
top_usernames = top_followers.nlargest(500).index
top_rows = spanish_tweets[spanish_tweets['username'].isin(top_usernames)]
In [26]:
cooccurrences = top_rows.groupby(['username','original_tweet_username']).size().reset_index(name='value')
cooccurrences2 = cooccurrences[cooccurrences.original_tweet_username != 0]
cooccurrences2.head()
Out[26]:
username original_tweet_username value
0 AlbertoRavell ReporteYa 1
1 AlbertoRavell UKR_token 1
2 CABLENOTICIAS yinaramostv 2
3 Canales11y13 T13Noticias 1
4 Canales3y7 Noti7Guatemala 1
In [27]:
len(cooccurrences2)
Out[27]:
64
In [28]:
chord = hv.Chord(cooccurrences2)
chord.opts(
    opts.Chord(edge_color='value', labels='index', node_color='index',cmap='Category20', title='Top 100 de usuarios refiriéndose a otros usuarios'))
Out[28]:
In [ ]: